Skip to content
121 linesCodeBlameRaw
1import { Stack, useLocalSearchParams } from "expo-router";
2import * as WebBrowser from "expo-web-browser";
3import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native";
4import { useState } from "react";
5import { View } from "react-native";
6
7import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code";
8import { Segments } from "@/components/segments";
9import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui";
10import { usePull, usePullChanges, useReview } from "@/lib/code";
11import { siteUrl } from "@/lib/server";
12import { useSignedIn } from "@/lib/session";
13import { ago } from "@/lib/time";
14import { space } from "@/theme";
15
16/**
17 * A pull request: what it is for, its checks, the conversation, and what
18 * it changes; approve it or ask for changes from here. Merging stays on
19 * the site for now, where its rules and the merge queue are shown.
20 */
21export default function PullRequest() {
22 const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
23 const number = Number(params.number);
24 const { server } = useSignedIn();
25 const [tab, setTab] = useState<"conversation" | "files">("conversation");
26 const pull = usePull(params.owner, params.repo, number);
27 const changes = usePullChanges(params.owner, params.repo, number, tab === "files");
28 const review = useReview(params.owner, params.repo, number);
29 const [note, setNote] = useState("");
30
31 const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`));
32
33 if (pull.isPending) {
34 return (
35 <Screen>
36 <Stack.Screen options={{ title: `#${number}` }} />
37 <Loading />
38 </Screen>
39 );
40 }
41 if (pull.error) {
42 return (
43 <Screen>
44 <Stack.Screen options={{ title: `#${number}` }} />
45 <ErrorView error={pull.error} retry={() => pull.refetch()} />
46 </Screen>
47 );
48 }
49
50 const { pull: pr, comments } = pull.data;
51 const by = pr.author.display_username ?? pr.author.username;
52 const reviewable = pr.status === "open";
53
54 return (
55 <Screen scroll>
56 <Stack.Screen options={{ title: `#${number}` }} />
57 <View style={{ gap: space.sm }}>
58 <Text size="heading">{pr.title}</Text>
59 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}>
60 <StateBadge pull={pr} />
61 <Checks status={pr.check_status} />
62 <Text size="small" tone="muted">
63 {by}
64 {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)}
65 </Text>
66 </View>
67 </View>
68
69 <Segments
70 value={tab}
71 onChange={setTab}
72 options={[
73 { value: "conversation", label: "Conversation" },
74 { value: "files", label: `Files · ${pr.files.length}` },
75 ]}
76 />
77
78 {tab === "conversation" ? (
79 <>
80 <Body text={pr.body} />
81 <Timeline comments={comments} />
82 {reviewable ? (
83 <View style={{ gap: space.md }}>
84 <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline />
85 <View style={{ flexDirection: "row", gap: space.sm }}>
86 <View style={{ flex: 1 }}>
87 <Button
88 title="Approve"
89 icon={Check}
90 busy={review.isPending && review.variables?.verdict === "approve"}
91 disabled={review.isPending}
92 onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })}
93 />
94 </View>
95 <View style={{ flex: 1 }}>
96 <Button
97 title="Request changes"
98 variant="secondary"
99 icon={MessageSquareWarning}
100 busy={review.isPending && review.variables?.verdict === "request_changes"}
101 disabled={review.isPending || !note.trim()}
102 onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })}
103 />
104 </View>
105 </View>
106 {review.error ? <Text tone="danger">{review.error.message}</Text> : null}
107 </View>
108 ) : null}
109 </>
110 ) : changes.isPending ? (
111 <Loading />
112 ) : changes.error ? (
113 <ErrorView error={changes.error} retry={() => changes.refetch()} />
114 ) : (
115 <Diff changes={changes.data} />
116 )}
117
118 <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} />
119 </Screen>
120 );
121}